Ibrahim Sowunmi

Implementing Stripe Payment Element and Express Checkout in Next.js 14 (App router)

Background

This project focuses on developing a simple Next.js 14 (app router) application that facilitates one-time Stripe payments. We’ll use Stripe Payment Intents alongside the Payment Element and Express Checkout Element. I’m making the assumption that you have priot experience with the technologies in this post, as this is a high level guide. Additionally, you can also scroll straight down to the full code.

Develop the PaymentIntent API endpoint

Set up the back-end using the app router. Create a folder /api/create-payment-intent for the payment intent and add a route.ts file.

Populate route.ts with a POST request to the Stripe server, including the amount, passed over from our request on the client side. The response should contain a payload and a status. For a successful payment, it will include the payment intent client secret and a status code of 200.

https://nextjs.org/docs/app/building-your-application/routing https://nextjs.org/docs/app/api-reference/functions/next-response

import { NextResponse, NextRequest } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const data = await req.json();
  const { amount } = data;

  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: Number(amount),
      currency: "USD",
      automatic_payment_methods: {
        enabled: true,
      },
    });

    return NextResponse.json(
      { client_secret: paymentIntent.client_secret },
      { status: 200 },
    );
  } catch (error: any) {
    return NextResponse.json(
      { message: error.message },
      { status: 400 }
    )
  }
}

Create a checkout page

Create a page.tsx file in the /app/checkout directory.

This page checks the cart (in this example, reading from the context API) on visit. Once the value has loaded, it sends a fetch request.

const { totalPrice } = useCartContext()
const [clientSecret, setClientSecret] = useState('');

useEffect(() => {
  if (!totalPrice) {
    return;
  }

  if (clientSecret) {
    console.log('Client secret already exists, skipping fetch');
    return;
  }

  fetch('/api/payment-intent', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ amount: totalPrice })
  })
    .then(res => res.json())
    .then(data => {
      setClientSecret(data.client_secret)
    })
    .catch(error => console.error('Error:', error))
}, [totalPrice])

This fetch request hits the payment-intents API endpoint we created and parses the total value in the cart. We receive a client secret as a response and store it in the state.

In the render, pass the Stripe Elements Provider and a Checkout Form:

{clientSecret && (
  <>
    <Elements options={{ clientSecret, appearance: { theme: 'flat' } }} stripe={stripePromise}>
      <CheckoutForm />
    </Elements>
  </>
)}

The Elements provider takes the client secret and the stripe promise, allowing prop sharing within the provider to access the props without prop drilling.

https://docs.stripe.com/stripe-js/react#elements-provider

Create components to render Stripe elements

Create a component called CheckoutForm. This code gets the stripe value and the element value with the useStripe and useElements hooks. These hooks allow us to read values from the Elements provider we previously created.

Add a Payment Element in line with the docs. Wrap the Payment Element in a form and add an onSubmit handler:

<form onSubmit={handleSubmit}>
  <PaymentElement />
  <button disabled={isLoading || !stripe || !elements}>
    {isLoading ? "Loading..." : "Pay now"}
  </button>
  {message && <div id="payment-message">{message}</div>}
</form>

Create the submit handler:

https://docs.stripe.com/js/payment_intents/confirm_payment

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!stripe || !elements) {
    return;
  }

  setIsLoading(true);

  const result = await stripe.confirmPayment({
    elements,
    confirmParams: {
      return_url: window.location.href,
    }
  })

  if (result.error) {
    console.error(result.error.message);
  }
}

Add a useEffect hook to check for the payment status:

https://docs.stripe.com/js/payment_intents/retrieve_payment_intent

useEffect(() => {
  if (!stripe) {
    return;
  }

  const clientSecret = new URLSearchParams(window.location.search).get(
    "payment_intent_client_secret"
  );

  if (!clientSecret) {
    return;
  }

  stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
    switch (paymentIntent?.status) {
      case "succeeded":
        setMessage("Payment succeeded!");
        break;
      case "processing":
        setMessage("Your payment is processing.");
        break;
      case "requires_payment_method":
        setMessage("Your payment was not successful, please try again.");
        break;
      default:
        setMessage("Something went wrong.");
        break;
    }
  });
}, [stripe]);

This is a basic example for illustration purposes. Now if we add a few more of the bells and whistles in terms of error handling and imports this is what a basic example of the full code looks like.

Full code

CheckoutForm.tsx

import { 
  ExpressCheckoutElement, 
  PaymentElement, 
  useElements, 
  useStripe } 
from '@stripe/react-stripe-js';
import { useEffect, useState } from 'react';

const CheckoutForm = () => {
  const stripe = useStripe();
  const elements = useElements();

  const [message, setMessage] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!stripe) {
      return;
    }

    const clientSecret = new URLSearchParams(window.location.search).get(
      "payment_intent_client_secret"
    );

    if (!clientSecret) {
      return;
    }

    stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
      switch (paymentIntent?.status) {
        case "succeeded":
          setMessage("Payment succeeded!");
          break;
        case "processing":
          setMessage("Your payment is processing.");
          break;
        case "requires_payment_method":
          setMessage("Your payment wasn't successful, please try again.");
          break;
        default:
          setMessage("Something went wrong.");
          break;
      }
    });
  }, [stripe]);

  const handleConfirm = async () => {
    if (!stripe || !elements) {
      return;
    }
    const result = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: window.location.href,
      }
    });

    if (result.error) {
      console.error(result.error.message);
    }
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) {
      return;
    }

    setIsLoading(true);

    const result = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: window.location.href,
      }
    });

    if (result.error) {
      console.error(result.error.message);
    }
  };

  return (
    <>
      <div style={{ height: '100px' }}>
        <ExpressCheckoutElement onConfirm={handleConfirm} />
      </div>
      <form onSubmit={handleSubmit}>
        <PaymentElement />
        <button disabled={isLoading || !stripe || !elements}>
          {isLoading ? "Loading..." : "Pay now"}
        </button>
        {message && <div id="payment-message">{message}</div>}
      </form>
    </>
  );
};

export default CheckoutForm;

Page.tsx

import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { useEffect, useState } from 'react';
import CheckoutForm from './CheckoutForm';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

const CheckoutPage = () => {
  const [clientSecret, setClientSecret] = useState('');

  useEffect(() => {
    fetch('/api/payment-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount: 1000 }) // Replace with your actual amount
    })
      .then((res) => res.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, []);

  return (
    <div>
      {clientSecret && (
        <Elements options={{ clientSecret }} stripe={stripePromise}>
          <CheckoutForm />
        </Elements>
      )}
    </div>
  );
};

export default CheckoutPage;

Route.ts

import { NextResponse, NextRequest } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const data = await req.json();
  const { amount } = data;

  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: Number(amount),
      currency: "USD",
      automatic_payment_methods: {
        enabled: true,
      },
    });

    return NextResponse.json(
      { clientSecret: paymentIntent.client_secret },
      { status: 200 },
    );
  } catch (error: any) {
    return NextResponse.json(
      { message: error.message },
      { status: 400 }
    );
  }
}

Happy coding!